package liquidfs.data.fs.util;

import java.io.*;

import utility.*;

import liquidfs.core.*;
import liquidfs.data.*;
import liquidfs.data.ex.*;
import liquidfs.data.fs.*;
import liquidfs.data.ops.*;

public class LiquidDataCache {
   
   private LiquidFile file;
   private LiquidFileSystem fs;
   
   private int numBlocks;
   private int blockSize;
   private boolean detectChanges;
   private boolean open = false;
   private OpenMode mode;
   
   private long fileLength = -1;
   private boolean overrideFileLength = false;
   
   private Revision revision;
   
   private Block[] blocks;
   
   private OutputStream out;
   private InputStream in;
   
   public LiquidDataCache(LiquidFileSystem fs, LiquidFile file) {
      this (fs, file, 4, 4, true);
   }
   
   public LiquidDataCache(LiquidFileSystem fs, LiquidFile file, int numBlocks, int blockSize, boolean detectChanges) {
      this.fs = fs;
      this.file = file;
      this.numBlocks = numBlocks;
      this.blockSize = blockSize;
      this.detectChanges = detectChanges;
      this.revision = file.getMetadata().getRevision();
      
      this.blocks = new Block[numBlocks];
   }
   
   public int getNumberOfBlocks() {
      return this.numBlocks;
   }
   
   public int getBlockSize () {
      return this.blockSize;
   }
   
   public void open (OpenMode mode) {
      this.mode = mode;
      this.open = true;
   }
   
   public void close () {
      
      // If the file is still open
      if (this.open) {
         
         // Unload the buffer
         if (this.mode == OpenMode.READ_WRITE)
            this.unload();
         
         // Mark the file as closed
         this.open = false;
      }
   }
   
   public boolean isOpen () {
      return this.open;
   }
   
   public void truncate () {
      this.setLength(0);
   }
   
   public InputStream getInputStream () {
      return new LiquidDataCacheInputStream(this);
   }
   
   public InputStream getInputStream(long position) {
      return new LiquidDataCacheInputStream(this, position);
   }
   
   public OutputStream getOutputStream () {
      return new LiquidDataCacheOutputStream(this);
   }
   
   public OutputStream getOutputStream (long position) {
      return new LiquidDataCacheOutputStream(this, position);
   }
   
   protected int read (long position, byte[] buffer, int offset, int length) {
      return copy (position, buffer, offset, length, false);      
   }
   
   protected int write (long position, byte[] buffer, int offset, int length) {
      
      if (mode == OpenMode.READ) {
         throw new UnsupportedOperationException("Attempted to write to a File opened in read-only mode.");
      }
      
      // Extend the length of the file, if we're writing past the end of it
      if (overrideFileLength) {
         long end = offset + length;
         if (end > fileLength)
            fileLength = end;
      }
      
      return copy (position, buffer, offset, length, true);
   }
   
   protected int copy (long position, byte[] buffer, int offset, int length, boolean write) {
      
      // Get the block index of the first block
      long firstBlockIndex = position / blockSize;
      
      // Get the block index of the last block
      long lastBlockIndex = (position + length - 1) / blockSize;
      
      // Keep track of the position we're currently copying from (start it at the offset)
      long currentPosition = position;
      
      // Keep track of how much we have left to copy (start with the total)
      int remaining = length;
      
      // Keep track of how much we've copied
      int total = 0;
      
      // Keep track of where we are in the buffer
      int bufferOffset = offset;
      
      // Loop through each block that we're copying
      for (long blockIndex = firstBlockIndex; blockIndex <= lastBlockIndex; blockIndex++) {
         
         // The position of the start of the block
         long blockStart = blockIndex * blockSize;
         
         // Get the offset within the block
         int blockOffset = (int) (currentPosition - blockStart);  // This is guaranteed to be an int, since blockSize is an int
         
         // Get the block at that position
         Block block = retrieveBlock(blockIndex, write);
         
         // Get the block's data
         byte[] data = block.getData();
         
         // Keep track of how much we copy
         int blockCopyLength;
         
         // If we're writing
         if (write) {
            
            // Get the number of bytes remaining in this block
            int blockRemaining = (blockSize - blockOffset);
            
            // Get the amount to copy within this block
            blockCopyLength = remaining < blockRemaining ? remaining : blockRemaining;
            
            // Copy the buffer into the data
            boolean changed = false;
            
            // Use this to be slightly slower, but to know whether anything was actually changed or not (thus preventing needless writes)
            if (detectChanges && block.wasRead()) {
               for (int i = 0; i < blockCopyLength; ++i) {
                  if ( data[blockOffset] != (data[blockOffset + i] = buffer[bufferOffset + i]) ) {
                     changed = true;
                  }
               }
            }
            // Use this to be fast, but to assume things have been changed
            else {
               changed = true;
               System.arraycopy(buffer, bufferOffset, data, blockOffset, blockCopyLength);
            }
            
            // Mark this block as changed, if necessary
            block.setChanged(changed);
            
            // Update the length of the block, if necessary
            if (blockOffset + blockCopyLength > block.getLength()) {
               block.setLength(blockOffset + blockCopyLength);
            }
         }
         // If we're reading
         else {
            
            // Get the number of bytes remaining that can be read in this block
            int blockRemaining = (block.getLength() - blockOffset);
            
            // Check if we're done
            if (blockRemaining <= 0)
               break;
            
            // Get the amount to copy within this block
            blockCopyLength = remaining < blockRemaining ? remaining : blockRemaining;
            
            // Copy the data into the buffer
            System.arraycopy(data, blockOffset, buffer, bufferOffset, blockCopyLength);
            
         }
         
         // Update how much we have left to copy
         remaining -= blockCopyLength;
         
         // Update the total of how much we've copied
         total += blockCopyLength;
         
         // Move forward in the buffer
         bufferOffset += blockCopyLength;
         
         // Move the position forward
         currentPosition += blockCopyLength;
         
         // Check if we've reached the end
         if (currentPosition < ((blockIndex+1) * blockSize)) {
            break;
         }
      }
      
      return total;
   }
   
   protected Block retrieveBlock (long blockIndex, boolean write) {
      
      // Get the position in the block buffer that this block would be located at
      int index = (int) (blockIndex % numBlocks); // This is guaranteed to be an int, because numBlocks is an int
      
      // Get the block at that index
      Block block = blocks[index];
      
      // If the block is (a) loaded, (b) the block we want, and (c) is for writing or contains data read from the file, then we don't need to do anything
      if (block != null && block.getIndex() == blockIndex && (write || block.wasRead()))
         return block;
      
      // Otherwise, unload the old block in that position
      if (block != null && block.isChanged()) {
         this.writeBlock(block);
      }
      
      // If we're reading block, or we need to detect changes, read a new block in
      if (detectChanges || !write) {
         block = blocks[index] = this.readBlock(blockIndex);
      }
      // If we're writing, just make a block to store the written data
      else {
         block = blocks[index] = new Block(blockIndex, 0, new byte[blockSize], false);
      }
      
      return block;
   }
   
   @Override
   public void finalize () {
      this.close();
   }
   
   public void unload () {
      
      // Create a sequential input stream to store multiple sequential block buffers
      SequentialByteBufferInputStream in = null;
      
      // Keep track of the offset to write at
      long offset = -1;
      
      // Unload all of the blocks
      for (int i = 0; i < blocks.length; ++i) {
         
         // Get the block at this index
         Block block = blocks[i];
         
         // Check if the block has been changed
         boolean changed = block != null && block.isChanged();
         
         // If this block isn't loaded, then our sequence has ended, so write it out
         if (!changed && in != null) {
            this.writeSource(offset, in, in.length());
            in = null;
            offset = -1;
         }
         
         // If this block is loaded and we haven't started a new sequence yet, start one
         else if (changed && in == null) {
            in = new SequentialByteBufferInputStream(10);
            in.add(block.data, block.length);
            offset = block.index * this.getBlockSize();
         }
         
         else if (changed && in != null) {
            
            // If it's not immediately after the previous block, write it out and create a new stream
            if (block.index != blocks[i - 1].index + 1) {
               this.writeSource(offset, in, in.length());
               in = new SequentialByteBufferInputStream(10);
               offset = block.index * this.getBlockSize();
            }
            
            // Add this buffer to the end of the stream
            in.add(block.data, block.length);
         }
      }
      
      // Clear the blocks for good
      for (int i = 0; i < blocks.length; ++i) {
         blocks[i] = null;
      }
      
      // Write last sequence, if we have one
      if (in != null)
         this.writeSource(offset, in, in.length());
      
      // If we're overriding the file length, and we haven't written that out yet, do it now
      if (this.overrideFileLength) {
         this.setSourceLength(this.fileLength);
         
         // Once it's been overridden once, turn it off
         this.overrideFileLength = false;
         this.fileLength = -1;
      }
   }
   
   protected Block readBlock(long index) {
      
      // Create a buffer of the appropriate size
      int size = this.getBlockSize();
      byte[] buffer = new byte[size];
      
      // Read the block from our source
      ByteBufferOutputStream out = new ByteBufferOutputStream(buffer);
      int read = this.readSource(index * size, out, size);
      
      // If there is no block here (i.e., we're past the end of the file) return null
      if (read < 0)
         read = 0;
      
      // Return the block
      return new Block(index, read, buffer);
   }

   protected void writeBlock(Block block) { 
      ByteBufferInputStream in = new ByteBufferInputStream(block.getData());
      this.writeSource(block.getIndex() * this.getBlockSize(), in, block.getLength());     
   }
   
   protected int readSource(long offset, OutputStream out, int length) {
      
      // Do this through an operation
      ReadFileOperation op = new ReadFileOperation (fs.getLocalDevice(), file.getPath(), offset, length);
      Result<Integer> result = fs.process(op, null, out);
      
      // Determine how many bytes we read
      return result.getData();
   }
   
   protected void writeSource (long offset, InputStream in, int length) {
      
      if (this.mode != OpenMode.READ_WRITE) {
         throw new FileSystemException("File " + file.getPath() + " not opened for writing.");
      }
      
      // If the file length is less than the buffer size (i.e., we're trimming the file), don't send the whole block
      if (overrideFileLength) {
         length = (offset + length < this.fileLength ? length : (int)(this.fileLength - offset));  // guaranteed to be an int because offset + length is greater 
                                                                                                   // than fileLength and length is only an int
         // If this block is completely passed the end of the file, don't write anything at all
         if (length < 0)
            return;
      }
      
      Revision nextRevision = new Revision(fs.getConfiguration().getLocalDevice(), this.revision.getRevisionNumber() + 1);
      
      // Create an operation to send to the other devices
      WriteFileOperation op = new WriteFileOperation(
         nextRevision,
         this.revision,
         this.file.getPath(),                            // Write this file
         offset,                                         // Offset of the block
         length                                          // Size of the block
      );
      
      // Update the revision
      this.revision = nextRevision;
      
      // If we're overriding the length of the file, add the current file length to the write operation
      if (overrideFileLength) {
         op.setFileLength(this.fileLength);
         
         // Once it's been overridden once, we don't need to keep adding it to every write operation
         this.overrideFileLength = false;
         this.fileLength = -1;
      }
      
      // Process the write operation
      fs.process(op, in, null);
   }

   public void setLength (long length) {
      this.overrideFileLength = true;
      this.fileLength = length;
   }
   
   protected void setSourceLength (long length) {
      
      if (this.mode != OpenMode.READ_WRITE) {
         throw new FileSystemException("File " + file.getPath() + " not opened for writing.");
      }
      
      Revision nextRevision = new Revision(fs.getConfiguration().getLocalDevice(), this.revision.getRevisionNumber() + 1);
      
      // Create an operation to propagate these changes to the other nodes on the network
      SetFileLengthOperation op = new SetFileLengthOperation(
         nextRevision, 
         this.revision, 
         file.getPath(), 
         length
      );
      
      // Update the revision
      this.revision = nextRevision;
      
      // Process the operation
      fs.process(op);
   }
   
   protected class Block {
      
      private long index;
      private int length;
      private byte[] data;
      private boolean changed = false;
      private boolean read = true;
      
      public Block (long index, int length, byte[] data) {
         this.index = index;
         this.length = length;
         this.data = data;
      }
      
      public Block (long index, int length, byte[] data, boolean read) {
         this.index = index;
         this.length = length;
         this.data = data;
         this.read = read;
      }
      
      public long getIndex () {
         return this.index;
      }
      
      public int getLength () {
         return this.length;
      }
      
      public void setLength (int length) {
         this.length = length;
      }
      
      public byte[] getData () {
         return this.data;
      }
      
      public boolean isChanged () {
         return this.changed;
      }
      
      public void setChanged (boolean changed) {
         this.changed = changed;
      }
      
      public boolean wasRead () {
         return this.read;
      }
   }
   
   private class LiquidDataCacheInputStream extends InputStream {

      private LiquidDataCache cache;
      private long position = 0;
      private byte[] temp = new byte[1];
      
      public LiquidDataCacheInputStream (LiquidDataCache cache) {
         this.cache = cache;
      }
      
      public LiquidDataCacheInputStream (LiquidDataCache cache, long initialPosition) {
         this.cache = cache;
         this.position = initialPosition;
      }
      
      @Override
      public int read() throws IOException {
         cache.read(position++, temp, 0, 1);
         return temp[0];
      }
      
      @Override
      public int read(byte[] buffer) throws IOException {
         int read = cache.read(position, buffer, 0, buffer.length);
         position += read;
         return read;
      }
      
      @Override
      public int read (byte[] buffer, int offset, int length) throws IOException {
         int read = cache.read(position, buffer, offset, length);
         position += read;
         return read;
      }
   }
   
   private class LiquidDataCacheOutputStream extends OutputStream {
      private LiquidDataCache cache;
      private long position = 0;
      byte[] temp = new byte[1];
      
      public LiquidDataCacheOutputStream (LiquidDataCache cache) {
         this.cache = cache;
      }
      
      public LiquidDataCacheOutputStream (LiquidDataCache cache, long initialPosition) {
         this.cache = cache;
         this.position = initialPosition;
      }
      
      @Override
      public void close () {
         this.cache.close();
      }
      
      @Override
      public void write (int b) {
         temp[0] = (byte) b;
         cache.write(position++, temp, 0, 1);
      }
      
      @Override
      public void write (byte[] buffer) {
         cache.write(position, buffer, 0, buffer.length);
         position += buffer.length;
      }
      
      @Override
      public void write (byte[] buffer, int offset, int length) {
         cache.write(position, buffer, offset, length);
         position += length;
      }
   }
   
}
